Skip to content

perf(www): cache index.html at edge, lazy-load auth dialogs#141

Merged
guidefari merged 2 commits into
prodfrom
perf/www-cache-and-lazy-preload
Jul 10, 2026
Merged

perf(www): cache index.html at edge, lazy-load auth dialogs#141
guidefari merged 2 commits into
prodfrom
perf/www-cache-and-lazy-preload

Conversation

@guidefari

@guidefari guidefari commented Jul 10, 2026

Copy link
Copy Markdown
Owner

Summary

Follow-up to #139. A cold Lighthouse run against production (desktop, no cache) showed LCP at 1.9s, worse than the 1.0s that PR claimed post-fix — so there was still room. This PR addresses the two biggest levers found.

What changed

1. infra/www.ts — edge-cache index.html

Before: index.html was served with Cache-Control: max-age=0,no-cache,no-store,must-revalidate — SST's aws.StaticSite default for HTML files (docs).

Problem: Cloudflare is proxying in front of CloudFront (infra/www.tsdns: sst.cloudflare.dns({ proxy: true })). Both layers respect standard Cache-Control semantics (MDN: Cache-Control), and no-store tells them never to cache the document at edge. Every cold visit — first-time visitor, or anyone hitting a CloudFront PoP after cache expiry — pays a full origin round-trip just to get the HTML shell, before the browser can even start parsing.

Measured directly against prod:

run 1 (cold):  TTFB 1.33s   x-cache: RefreshHit from cloudfront   (origin round-trip)
run 2 (warm):  TTFB 0.06s   x-cache: Hit from cloudfront
run 3 (warm):  TTFB 0.06s   x-cache: Hit from cloudfront

Lighthouse's server-response-time audit independently flagged 829ms on the root document with ~729ms of possible savings.

Static hashed assets (/assets/*.js, .css) were already correct — public, max-age=31536000, immutable — no change needed there.

Fix: override assets.fileOptions to give HTML a 60s edge TTL instead of no-store:

assets: {
  fileOptions: [
    { files: '**', cacheControl: 'max-age=31536000,public,immutable' },
    { files: '**/*.html', cacheControl: 'public,max-age=60,must-revalidate' }
  ]
}

This is the documented override point for sst.aws.StaticSite (StaticSiteArgs.assets); order matters because later fileOptions entries win over earlier globs — see sst#5673 for a real-world case of this glob-ordering silently breaking someone's intended cache config, which is why the catch-all is listed first and the HTML override second, matching SST's own documented default ordering.

Trade-off: deploys become visible at each edge PoP within ~60s instead of instantly. For a solo project's release cadence this is negligible against the TTFB win.

2. apps/www/src/routes/__root.tsx — lazy-load closed-by-default dialogs

The deployed homepage's <head> carries 23 <link rel="modulepreload"> tags — the browser fetches, parses, and compiles all 23 chunks at high priority on every route load, home included. Lighthouse's unused-javascript audit showed why that's wasteful:

Chunk Transferred Unused Waste
index-*.js 335 KB 209 KB 62%
esm-*.js 72 KB 47 KB 65%
runtime-*.js 33 KB 21 KB 65%

__root.tsx statically imported AuthPromptDialog and WelcomeModal — both are dialogs that render null/closed by default (AuthPromptDialog opens only via a Zustand store flag; WelcomeModal opens only for authenticated users who haven't seen it, 500ms after mount). Because TanStack Router's autoCodeSplitting treats anything statically reachable from the root route as always-needed, both components' code (plus transitively use-toast, lucide-react icon chunks, and part of the better-auth client) were forced into the eager preload graph rather than route-level chunks.

AppShell.tsx already established the right pattern for this exact situation — QueueColumn is lazy-loaded there:

const QueueColumn = lazy(() =>
  import('@/components/queue/QueueColumn').then((m) => ({ default: m.QueueColumn }))
)

This PR applies the same pattern to the two root-level dialogs:

const AuthPromptDialog = lazy(() =>
  import('@/components/AuthPromptDialog').then((m) => ({ default: m.AuthPromptDialog }))
)
const WelcomeModal = lazy(() =>
  import('@/components/onboarding/WelcomeModal').then((m) => ({ default: m.WelcomeModal }))
)

wrapped in <Suspense fallback={null}> around both.

Why Suspense here specifically, and why it's safe: React.lazy() returns a component whose module import is deferred until first render — but React needs to suspend rendering while that import promise resolves, and Suspense is what catches that and shows a fallback in the meantime. Without wrapping <WelcomeModal />/<AuthPromptDialog /> in <Suspense>, React throws because there's nothing to catch the pending promise on first mount. fallback={null} is correct (not just convenient) because both components render nothing visible until their own internal open/isOpen state flips true — there is no meaningful loading state to show, so null costs nothing visually. Net effect: their code (and the toast/icon/auth-client chunks only they pulled in) moves out of the initial modulepreload set and into an on-demand chunk fetched only when a user actually opens sign-in or gets the welcome dialog.

Confirmed via build: AuthPromptDialog now emits as its own chunk (AuthPromptDialog-*.js, 4.88 kB) instead of being folded into index-*.js.

Known remaining limit: the modulepreload count didn't drop to zero-bloat — FloatingMenu (rendered on every route via AppShell) legitimately calls useSession() for nav state, which is real product logic, not incidental bloat, and it keeps pulling in the better-auth client chunks (bundle-mjs, middleware, useMutation) eagerly. Shrinking that further would mean swapping better-auth's client for a lighter session-check-only path — a bigger, separate effort, out of scope here.

3. infra/www.ts — migrate static site from AWS (S3+CloudFront) to Cloudflare Workers

DNS for goosebumps.fm already lives on Cloudflare with the proxy on (see infra/www.ts's old dns: sst.cloudflare.dns({ proxy: true })), so S3+CloudFront sat behind the Cloudflare edge — a redundant second CDN hop, and a second place cache headers had to be reasoned about (directly what made change #1 above more complicated than it needed to be).

Before: sst.aws.StaticSite — provisions S3 bucket + CloudFront distribution + ACM cert, cache headers set via assets.fileOptions.

After: sst.cloudflare.StaticSiteV2 — provisions a Cloudflare Worker with a native static-assets binding, no AWS resources, no second CDN hop. This is SST's own current recommended pattern for a Vite SPA on Cloudflare, not something improvised — matches their reference example exactly:

Links point at tag v4.15.2, matching the sst version pinned in this repo's package.json.

Cache config had to move, not just get deleted. StaticSiteV2 has no assets.fileOptions equivalent — the router worker (cf-static-site-router-worker-experimental) just delegates straight to env.ASSETS.fetch(), Cloudflare's native Workers Static Assets binding, which defaults every file — including hashed JS/CSS — to public, max-age=0, must-revalidate (Cloudflare docs). Left alone, that's a regression versus what we had.

Fix: added apps/www/public/_headers, Cloudflare's own convention for overriding this (same doc link above). Vite copies everything in public/ into dist/ untouched, so it ships as-is:

/assets/*
  Cache-Control: public, max-age=31536000, immutable

/fonts/*
  Cache-Control: public, max-age=31536000, immutable

/*.html
  Cache-Control: public, max-age=60, must-revalidate

Same effective policy as the old fileOptions block — just expressed in the new platform's native mechanism instead of SST's.

Impact of the 1yr immutable cache on /assets/*, and how it gets busted: this is safe specifically because Vite content-hashes every built filename — e.g. index-CSxTSgJW.js, where CSxTSgJW is a hash of the file's contents. A code change produces a new filename, never a mutated old one. index.html (60s cache, not 1yr) is what references those hashed filenames via <script src="/assets/index-CSxTSgJW.js">. So the actual invalidation path on deploy is:

  1. New build emits new hashed filenames for anything that changed.
  2. New index.html (and any other .html) referencing the new hashes goes live at edge within 60s (the HTML's own cache TTL).
  3. Old hashed asset files are simply never requested again once no live HTML points at them — nothing needs to actively evict them from cache, they just age out of relevance. Old cached copies at the edge or in browsers are harmless: correct-but-unreferenced, not stale-and-served.
    The only thing that would break this is emitting HTML without re-hashing referenced assets (not something Vite does) or serving stale index.html past its 60s TTL — which is exactly why HTML gets the short TTL and assets get the long one, not the reverse.

Test plan

  • bun run typecheck clean in apps/www
  • bun run build — confirmed AuthPromptDialog split into its own chunk
  • Verified visually in browser (agent-browser): homepage renders correctly, floating menu and featured-mix card intact, no console errors from the lazy-loaded components
  • bun precommit clean after the Cloudflare migration (typecheck across all workspaces)
  • Deploy to prod and re-run cold Lighthouse to confirm TTFB/LCP improvement holds in the field
  • Confirm Cloudflare Worker route cutover for www.goosebumps.fm / goosebumps.fm completes cleanly with no DNS/routing gap

https://claude.ai/code/session_01N27hN1p1WPHzzHwbi7nrXX

Cold Lighthouse audit on goosebumps.fm showed LCP 1.9s despite prior
bundle-size work (#139). Two contributors found:

1. index.html served with no-store, so Cloudflare/CloudFront never
   cache it at edge. Every cold visit pays a full origin round-trip
   (measured 829ms-1.3s TTFB) before the browser has HTML to parse.
   Give HTML a short edge TTL instead.

2. Root route eagerly imported AuthPromptDialog and WelcomeModal,
   both closed-by-default dialogs, forcing their chunks (and
   transitively use-toast/lucide icon code) into the 23 modulepreload
   tags fetched on every route load including home. Lazy-load them
   like QueueColumn already is in AppShell.tsx.
…orkers

DNS already lives on Cloudflare (proxy: true), so S3+CloudFront behind
the Cloudflare edge was a redundant double-CDN hop, adding latency and
a second cache layer to reason about (directly complicating the prior
commit's index.html edge-cache change).

Swap sst.aws.StaticSite for sst.cloudflare.StaticSiteV2 (Workers +
static assets, SST's current recommended pattern per examples/cloudflare-vite).
Also drops CloudFront/ACM/S3 provisioning and AWS credentials from the
www deploy path.

StaticSiteV2 has no assets.fileOptions API — Workers Static Assets
defaults every file to 'public, max-age=0, must-revalidate'. Replaced
with a Cloudflare-native apps/www/public/_headers file, copied verbatim
into dist/ by Vite, to keep the same immutable long-cache for hashed
assets/fonts and 60s revalidate for HTML.
@guidefari guidefari merged commit 23987c5 into prod Jul 10, 2026
4 checks passed
github-actions Bot pushed a commit that referenced this pull request Jul 10, 2026
## [2.65.2](v2.65.1...v2.65.2) (2026-07-10)

### Performance Improvements

* **www:** cache index.html at edge, lazy-load auth dialogs ([#141](#141)) ([23987c5](23987c5)), closes [#139](#139)
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 2.65.2 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

guidefari added a commit that referenced this pull request Jul 10, 2026
Follows the existing /changelog pattern: a real markdown doc
(docs/PERF_LOG.md) compiled to MDX at build time via a Vite virtual
module, so the page can't drift from what's actually documented in
the repo.

Covers the full arc: bundle-size cut (#139), edge-caching + lazy
dialogs + AWS-to-Cloudflare migration (#141), and the root
cache-control follow-up fix, with before/after numbers measured
against production.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant